﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections;
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using System.Xml;
using System.IO;

namespace XPathAutoSuggest {
    /// <summary>
    /// Provides support for parsing XML and suggestion XPath queries using selected nodes/tags
    /// </summary>
    /// <remarks>
    /// </remarks>
    public class XmlHelper : IXPathUtility {

        /// <summary>
        /// The most important method in the class, it builds a number of XPath queries
        /// that match the selected text in the containing strng 'allXml'
        /// </summary>
        /// <param name="allXml"></param>
        /// <param name="selectedText"></param>
        /// <param name="selectionStart"></param>
        /// <returns></returns>
        public ObservableCollection<QueryDescPair> GetXPathQueries(string allXml, 
                                string selectedText, int selectionStart) {
            ObservableCollection<QueryDescPair> result = new ObservableCollection<QueryDescPair>();
            string tagName;
            string nodeVal;
            string indexedQuery;
            //The path will be something like "Bookstore/books/title", if it is a tag, "tagName"
            //will be non-empty, if it is present more than once, "ordinal" will represent the
            //position of users' selection, nodeVal will contain the full contents of the node,
            //useful in cases where user selected a node substring
            string path = GetNodePath(allXml, selectedText, selectionStart, out tagName, 
                                        out indexedQuery, out nodeVal);


            string initialQry;
            //Our logic for generating suggestions varies depending of whether user selected node or tag:
            if (!string.IsNullOrEmpty(tagName)) {
                initialQry = path + "[@" + tagName + "='" + selectedText + "']";

                result.Add(new QueryDescPair { Query = initialQry, Description = "Fetch node by attribute value" });
            } else if (string.IsNullOrEmpty(path)) {
                //Most likely user selected text inside a data or comment node
                return result;
            } else {
                result.Add(new QueryDescPair{ Query= path, Description=""});
                initialQry = path;
                int lastSlash = path.LastIndexOf("/");


                if (!string.IsNullOrEmpty(indexedQuery)) {
                    result.Add(new QueryDescPair { Query = indexedQuery, Description = "Seek elements using positions" });
                }

                //find all the counts. For example, if our indexed query is
                //      /countries[1]/country[2]/provinces[1]/province[3]
                //then the regular expression will find all the numbers in brackets
                string regexExpr = @"\[         #bracket character
                                    (           #start a capture group
                                    [^\]+]      #anything but ]
                                    )     		#close capture group
                                    \]          #close bracket character";
                Regex reGetCounts = new Regex(regexExpr, RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline);
                Match m = reGetCounts.Match(indexedQuery);

                //check to see if more than one query has an index greater than 1
                int multiCount = 0;
                while (m.Success) {
                    int ndx = int.Parse(m.Groups[1].Value);
                    if (ndx > 1)
                        multiCount++;
                    m = m.NextMatch();
                }

                //if there was exactly one index
                if (multiCount == 1) {
                }

                //Insert recommendations like /bookstore/book[price="9.95"]/price
                if (selectedText == nodeVal) {
                    string pathValQuery = initialQry.Substring(0, lastSlash) +
                                          "[" + initialQry.Substring(lastSlash + 1) + "=\"" + nodeVal + "\"]/" +
                                          initialQry.Substring(lastSlash + 1);
                    result.Add(new QueryDescPair { Query = pathValQuery, Description = "Select the node having value " + nodeVal });
                    double userVal;
                    if (double.TryParse(nodeVal, out userVal)) {
                        //suggest queries using >=, such as
                        //bookstore/book[price>=9.95]/title
                        string geQuery = initialQry.Substring(0, lastSlash) +
                                        "[" + initialQry.Substring(lastSlash + 1) +
                                        ">=" + selectedText + "]" +
                                        initialQry.Substring(lastSlash);
                        result.Add(new QueryDescPair { Query = geQuery, Description = "Select where numeric value greater than or equal" });
                    }
                } else {
                    //Make suggestions using Substring syntax, e.g.
                    //'/bookstore/book/title[substring(., 1, 6)='XPath ']'
                    int startPos = nodeVal.IndexOf(selectedText) + 1;
                    string pathValSustrQuery = initialQry +
                                          "[substring(., " + startPos.ToString() + ", " + selectedText.Length + ")='" + selectedText + "']"; 
                    result.Add(new QueryDescPair { Query = pathValSustrQuery, Description = "Select node on partial match" });
                }
            }

            return result;
        }

        /// <summary>
        /// Returns a simple path when given XML, selected text and selection start
        /// </summary>
        /// <param name="allXml">Contains caller's selection</param>
        /// <param name="selectedText">Text caller wants path for</param>
        /// <param name="selectionStart">Numeric index used for non-unique selections</param>
        /// <param name="tagName">Out, if populated, the tag name selected by user</param>
        /// <param name="indexedPath">A path using indexing</param>
        /// <param name="nodeVal">If user select a node of substring, will receive the full node text</param>
        /// <returns>A string like 'Bookstore/book/title'</returns>
        /// <remarks>
        /// <para>
        /// Algorithm: call GetTag to iterate the nodes until we advance past user's start position. 
        /// If we find an opening node, push it onto the stack. If we find a closing node, pop it
        /// off the stack. When we reach our target, iterate the stack and reverse it, using / to separate
        /// node names. The result is the path we return.
        /// </para>
        /// <para>
        /// Lambda expressions make it easy to build the return path with the following line of code:
        /// <pre><code>
        /// string nodePath = tagStack.Reverse&lt;XmlHelperNode&gt;()
        ///                     .Aggregate("", (s1, s2) => s1 + "/" + s2.TagName);
        /// </code></pre>
        /// </para>
        /// <para>
        /// The 'Reverse' method applies to Stack objects and returns an IEnumerable object
        ///  - which we then apply the Aggregate method to
        ///  - which we can contort to insert / characters between the nodes
        ///  - giving us a string result
        ///  </para>
        ///  <para>
        /// Note that the Aggregate variation shown above 
        ///  - uses a seed of empty string (first argument supplied to Aggregate)
        ///  - then creates the next 'sub-result' by concatenating the previous value (s1)
        ///     - with "/"
        ///     - and the tag name of the current enumeration, s2
        ///  
        /// </para>
        /// </remarks>
        public string GetNodePath(string allXml, string selectedText, int selectionStart,
                                out string tagName, out string indexedPath, 
                                out string nodeVal) {
            nodeVal = string.Empty;

            //This dictionary will hold counts such as 
            //      countries/states/cities: 4
            //      countries/states:        15
            //which can be used to generate a path like /countries/states[15]/cities[4]
            Dictionary<string, int> nodePathCounts = new Dictionary<string, int>();

            int p = 0;
            //we will use the stack to store every tag that needs a closing tag
            //when we discover any closing tag, we will pop the latest off the stack
            Stack<XmlHelperNode> nodeStack = new Stack<XmlHelperNode>();

            XmlHelperNode curNode = null;
            //as long as we haven't reached the position of caller's text, keep getting tags
            //and pushing them on the stack if they have a closing tag
            int tagStart = 0;
            while (p < selectionStart) {
                GetNode(allXml, out curNode, ref p, ref tagStart);
                if (curNode.NodeType == XmlHelperNodeType.ClosingTag && p > selectionStart + selectedText.Length)
                    break;
                if (curNode.NodeType == XmlHelperNodeType.ClosingTag) {
                    RemoveMoreSpecificPaths(nodePathCounts, nodeStack);
                    nodeStack.Pop();
                } else if (curNode.NodeType == XmlHelperNodeType.NormalWithClosingTag) {
                    nodeStack.Push(curNode);
                    string curPath = nodeStack.Reverse<XmlHelperNode>().Aggregate("", (s1, s2) => s1 + "/" + s2.NodeName);
                    if (nodePathCounts.ContainsKey(curPath))
                        nodePathCounts[curPath]++;
                    else
                        nodePathCounts.Add(curPath, 1);
                }
            }
            if (curNode.NodeType == XmlHelperNodeType.Comment || curNode.NodeType == XmlHelperNodeType.Question || curNode.NodeType == XmlHelperNodeType.XmlData) {
                tagName = string.Empty;
            } else {
                tagName = GetTagName(allXml, selectionStart, p, tagStart);
                if (string.IsNullOrEmpty(tagName)) {
                    nodeVal = GetNodeVal(allXml, selectionStart);
                }
            }

            RemoveMoreSpecificPaths(nodePathCounts, nodeStack);

            indexedPath = nodePathCounts.OrderBy(kvp => kvp.Key.Length)
                .Aggregate("", (s1, s2) => s1 + "/" + LastNode(s2.Key) + "[" + s2.Value.ToString() + "]");


            //Reverse the nodes and concatenate them via the aggregate function (using "" for a seed), separating each by "/" character
            string nodePath = nodeStack.Reverse<XmlHelperNode>().Aggregate("", (s1, s2) => s1 + "/" + s2.NodeName);

            return nodePath;
        }

        /// <summary>
        /// If user searches for a node that has peers, it causes problems when generating
        /// the path using the nodePathCounts, remove them here
        /// </summary>
        /// <param name="nodePathCounts">Contains counts of every path encountered so far</param>
        /// <param name="tagStack">Every tag we have encounterd so far, excluding irrelevant paths</param>
        /// <remarks>
        /// Suppose our XML looks like this:
        /// BookStore
        ///     Book
        ///     Music
        ///     
        /// When user seeks Music, we don't want Book to be used in the path, but we have entries for it
        /// in nodePathCounts, so here they get removed.
        /// </remarks>
        public void RemoveMoreSpecificPaths(Dictionary<string, int> nodePathCounts, Stack<XmlHelperNode> tagStack) {
            string curPath = tagStack.Reverse<XmlHelperNode>().Aggregate("", (s1, s2) => s1 + "/" + s2.NodeName);
            if (!string.IsNullOrEmpty(curPath)) {
                string parentPath = curPath.Substring(0, curPath.LastIndexOf('/'));
                if (nodePathCounts.ContainsKey(curPath)) {
                    //remove previously entered paths that were more specific thann the current:
                    string[] removeLst = (from n in nodePathCounts
                                          where (n.Key.StartsWith(parentPath) && n.Key != curPath && n.Key != parentPath)
                                          select n.Key
                                          ).ToArray();
                    foreach (string aKey in removeLst) {
                        if (nodePathCounts.ContainsKey(aKey))
                            nodePathCounts.Remove(aKey);
                    }
                }
            }
        }

        static string LastNode(string aNode) {
            int p = aNode.LastIndexOf('/');
            return p >= 0 ? aNode.Substring(p + 1) : aNode;
        }

        /// <summary>
        /// Returns the name of the tag we are currently on as indicated by p
        /// </summary>
        /// <param name="allXml">The XML we are working with</param>
        /// <param name="selPos">Position of users' tag/node</param>
        /// <param name="p">Where our 'pointer' currently is parsing the XML</param>
        /// <param name="nodeStart">Start of the tag we are in</param>
        /// <returns>A node name</returns>
        /// <example>
        /// If we seek the tag name 'lang within the XML substring shown:
        ///     &lt;title lang=""eng""&gt;Learning XML&lt;/title&gt;
        /// Then 
        ///     - p should indicate a position with the string 'eng'
        ///     - selPos should indicate a position after this
        ///     - nodeStart  should be the position just before &lt;title
        ///     - the return value will be 'lang'
        /// </example>
        public string GetTagName(string allXml, int selPos, int p, int nodeStart) {
            string tagName;
            //Want to detect if the text is inside a tag (such as an attribute)
            //or else is text content. StartTag points to the opening < and
            //p is the position immediately after the closing >
            if (nodeStart < selPos && selPos < p) {
                //we're looking at an attribute, we need to find the tag name
                //back-up to the character just before the equals sign
                p = selPos - 2;

                do {
                    p--;
                } while (p >= nodeStart && allXml[p] != ' ' && allXml[p] != '\t');
                tagName = allXml.Substring(p + 1, selPos - p - 3);
            } else {
                tagName = string.Empty;
            }
            return tagName;
        }

        /// <summary>
        /// Returns the entire node given an index to a point inside the node
        /// </summary>
        /// <param name="allXml">The XML containing the node in question</param>
        /// <param name="selectionStart">Index to a position within the node</param>
        /// <returns>The node text</returns>
        /// <example>
        /// allXml = &lt;book&gt;Learning XML&lt;/book&gt;
        /// selectionStart = 8
        /// result = "Learning XML"
        /// </example>
        public string GetNodeVal(string allXml, int selectionStart) {
            int p2 = allXml.IndexOf("<", selectionStart + 1);
            int p1 = allXml.Substring(0, selectionStart).LastIndexOf(">", selectionStart);
            string nodeVal = allXml.Substring(p1 + 1, p2 - p1 -1);
            return nodeVal;
        }

        /// <summary>
        /// Gets the node at the specified position p within allXml, updating tagStart to after the node
        /// </summary>
        /// <param name="allXml">The XML text to seek a tag within</param>
        /// <param name="aNode">Text of the node that will be returned</param>
        /// <param name="p">(ref) Index to the start, will be incremented</param>
        /// <param name="tagStart">Position within the text where we found result </param>
        /// <remarks>
        /// If the node is a data node, it is allowed to contain angle brackets, and
        /// this method handles that scenario, and hopefully all other legal nodes.
        /// 
        /// The node we generate is of type XmlHelperNode; that data type knows how
        /// to build itself from a raw sting, it also knows how to classify itself.
        /// </remarks>
        /// <example>
        /// If allXml = &lt;book lang="eng" &gt;Learning XML&lt;/book&gt;
        /// and 
        /// </example>
        public void GetNode(string allXml, out XmlHelperNode aNode, ref int p, ref int tagStart) {
            p = allXml.IndexOf("<", p);
            int p2 = p + 1;
            if (allXml.Substring(p, 9) == @"<![CDATA[") {
                p2 = allXml.IndexOf("]]>", p) + 3;
            } else {
                p2 = allXml.IndexOf(">", p);
            }

            tagStart = p;
            aNode = new XmlHelperNode(allXml.Substring(p, p2 - p + 1));
            p = p2 + 1;
        }

        /// <summary>
        /// Gets the number of nodes within allXml that are matched by the specified path
        /// </summary>
        /// <param name="allXMl">The XML containing nodes with peers</param>
        /// <param name="path">XPath query to one of the nodes who might have peers</param>
        /// <returns>Count</returns>
        public int GetPeerCount(string allXMl, string path) {
            System.Xml.XmlDocument xDoc = new System.Xml.XmlDocument();
            using (System.IO.StringReader sr = new System.IO.StringReader(allXMl)) {
                xDoc.Load(sr);
                return xDoc.SelectNodes(path).Count;
            }
        }

        /// <summary>
        /// Attempts to validate the XML string by loading it
        /// </summary>
        /// <param name="xml">Some XML that might have an error</param>
        /// <returns>Empty string if valid, otherwise an error message</returns>
        public string ValidateXml(string xml) {
            XmlDocument xDoc = new XmlDocument();
            StringReader strRdr = new StringReader(xml);
            try {
                xDoc.Load(strRdr);
            } catch (Exception ex) {
                return ex.Message;
            }

            return string.Empty;
        }
    }
}
